Skip to content

feat(auth): registration, JWT login and logout, password reset - #2

Merged
EdgarEldy merged 32 commits into
developfrom
feature/auth
Sep 19, 2026
Merged

EdgarEldy merged 32 commits into
developfrom
feature/auth

Conversation

@EdgarEldy

Copy link
Copy Markdown
Owner

Branch

feature/auth

Task checklist

  • core/auth entities and dto classes (User, ActivationToken, BlacklistedToken, PasswordResetToken)
  • RowMappers and UserMapper
  • UserDao and the token DAOs (JdbcTemplate, unannotated, entity types)
  • AuthService and AuthServiceImpl (register, activate, login, logout, forgot/reset password)
  • forgotPassword with the same outcome whether or not the email exists
  • core/auth DaoConfig and ServiceConfig
  • ws: JwtService, JwtAuthFilter, AuthController, UserConverter
  • WebMvcConfig imports the auth configuration (and the security configuration)
  • Tests at all five layers plus the full register, activate, login, /me flow

Commit summary

  • auth core: entities, dto, RowMappers, UserMapper, DAOs, AuthService (SHA-256 token digests, 72-byte password limit, fake bcrypt for unknown emails, idempotent logout), scheduled cleanup of expired blacklist rows
  • ws: RS256 JwtService and key loader, JwtAuthFilter rejecting revoked tokens, 401/403 in the ApiResponse format, stateless SecurityFilterChain, AuthController, payloads, DelegatingFilterProxy
  • dev key pair in dev-keys/ (outside the classpath), docker-compose with mounted production keys
  • tests: DAO (Testcontainers, one fixture per class), RowMapper (mocked ResultSet), mapper, service (Mockito), MockMvc controller and security chain, JWT, full flow proving the blacklist
  • review fix: blank email in forgotPassword

Deviations and limits are recorded in the project notes: no @EnableMethodSecurity yet (rbac), permissions live in the JWT until the next token, the cleanup only purges the blacklist.

Test plan

  • Tests exist at every layer the branch's checklist calls for (187 tests: common 14, auth 76, ws 97)
  • Each DAO test class loads its own dedicated SQL fixture file
  • mvn clean verify is green on the whole reactor
  • docker compose on the packaged WAR: register, activate, login, /me, logout, then the same token gets 401

The auth module needs only the password encoders of Spring Security, not
the whole security stack, so it depends on the small spring-security-crypto
artifact. Its version comes from the Spring Security BOM already imported
by the parent pom.

Keeping the web-facing Spring Security modules in ws preserves the rule
that core knows nothing about HTTP.
Services that compare or stamp times take a java.time.Clock instead of
calling Instant.now(). The production bean is Clock.systemUTC(), and a test
can build the service with a fixed clock to check expiry rules exactly.

It is declared once in CommonConfig, next to the other infrastructure
beans, so every module gets the same time source.
Entities are the raw, table-shaped view of a row: one class per table,
annotated with jakarta.persistence @table, @id and @column. Those
annotations are documentation only, since no JPA provider exists in this
project, they simply declare the table and column mapping next to the class.

User, ActivationToken, BlacklistedToken and PasswordResetToken are plain
classes with explicit accessors and equals/hashCode. Anything carrying a
password hash or a token digest masks it in toString so it never reaches
a log.
DTOs are the business shape a service exposes, a genuinely separate class
from the entity even when the fields match. UserDto carries the raw password
on input only and always null on output, plus the role names and the
RESOURCE:ACTION permission codes of the user.

BlacklistedTokenDto describes a token to revoke at logout. All of them mask
passwords and tokens in toString.
A RowMapper<T> is the JdbcTemplate callback that converts one ResultSet
row into one object. Each mapper reads its columns by name and does nothing
else: no query, no business rule.

timestamptz columns are read with getTimestamp and converted to Instant
through a small null-safe helper, because validated_at is NULL until an
activation token is consumed.
The entity mapper is the only place where the two shapes are converted, in
both directions. toDto never copies the password hash, and toEntity takes
the already hashed password as an explicit argument, so a raw password can
never be written into an entity by accident.

Roles and permissions are not columns of users: the service fills them in
from separate queries.
The DAO owns the SQL and works only in entity types. Lookups by email
compare LOWER(email) to match the case-insensitive unique index, insert
uses INSERT ... RETURNING id to get the generated key in one round trip, and
the permission query joins role_user, role_permission and permissions to
return distinct RESOURCE:ACTION codes.

The implementation extends AbstractDao and has no stereotype annotation: it
is declared by an explicit @bean in DaoConfig.
Activation and password reset tokens are looked up by their SHA-256 digest,
which is the only form ever stored. The blacklist DAO only probes, inserts
and purges: existsByTokenHash answers the per-request revocation check and
deleteExpiredBefore backs the scheduled cleanup.

All three run plain JdbcTemplate SQL through AbstractDao, bind Instants
with java.sql.Timestamp and work only in entity types.
AuthenticationFailedException is the base of every 401 case, so the web
layer maps one type. InvalidCredentialsException carries a single message
for an unknown email and a wrong password, and AccountNotActiveException
is raised only after the password was verified, so it never reveals whether
an email exists.

They live in core and know nothing about HTTP.
AuthServiceImpl holds the business rules and the transaction boundaries.
@transactional makes a Spring proxy open, commit or roll back a transaction
around each write, so a user row and its activation token succeed or fail
together. Tokens are 256 bits of SecureRandom output stored only as SHA-256
digests, with the raw value visible only in an INFO log line because no
mailer exists.

Login gives one error for an unknown email and a wrong password and runs a
fake bcrypt check for the unknown one to equalise timing. Passwords are
limited to 72 bytes, the bcrypt limit, and forgotPassword behaves the same
whether or not the account exists. Logout is idempotent, so a repeated
revocation of the same token is accepted.
DaoConfig and ServiceConfig declare every bean explicitly and return
them typed by interface, which is what lets @transactional apply through a
JDK proxy. The BCryptPasswordEncoder is exposed as the PasswordEncoder
abstraction.

@EnableScheduling registers the post-processor that runs @scheduled
methods: ExpiredTokenCleanupJob calls purgeExpiredTokens every day at 3 am to
delete blacklist rows whose token has expired anyway. The method is public,
so a test can call it directly.
ws now depends on spring-security-web and spring-security-config, the
web-facing half of Spring Security, and on jjwt: the API at compile time,
the implementation and Jackson binding at runtime only.

Surefire receives the two JWT key locations as system properties pointing
at the development key pair at the repository root, so the tests never read
keys from the classpath.
A 2048 bit RSA pair (PKCS#8 private key, X.509 public key) for local
development and the automated tests. It sits in dev-keys/ at the repository
root, never under src/main/resources, so it can never be packaged inside the
WAR.

The README of the directory states they are public and dev only.
Production keys are generated separately and mounted from a git-ignored
directory, and the application has no default key at all.
A JWT is a signed, self-contained bearer credential, so the API keeps no
session. RS256 is asymmetric: only the private key holder can issue tokens
and verification needs just the public key. JwtKeyLoader reads both PEM
files from configurable locations and fails startup with a clear message
when one is missing or of the wrong type.

Tokens carry the user id, a random jti, the email and the permission codes.
parse never throws: a bad signature, an expired or foreign token, or any
algorithm other than RS256 gives an empty result. The raw JWT is only ever
handled through its SHA-256 digest.
… tokens

A OncePerRequestFilter reads the Bearer token, verifies it and then asks
AuthService whether its SHA-256 digest is blacklisted. A valid, non revoked
token fills the SecurityContext with an AuthenticatedUser principal and one
authority per permission code taken from the token claims, so no database
call is needed to authorise a request.

For a missing, invalid or revoked token the context stays empty and the
entry point answers 401. The filter is created inside the SecurityFilterChain
and is not registered as a servlet filter.
The filters of Spring Security run before the DispatcherServlet, so
GlobalExceptionHandler never sees their failures. An AuthenticationEntryPoint
handles unauthenticated requests and an AccessDeniedHandler handles
authenticated requests lacking a right; both write an ApiResponse error as
JSON with the shared ObjectMapper.

Every error of the API therefore has the same shape, including those raised
by the security layer.
@EnableWebSecurity registers the infrastructure and the springSecurityFilterChain
filter. The chain is a plain @bean: CSRF is disabled because this is a token
API without cookies, sessions are STATELESS, the register, activation, login,
forgot and reset routes and the health check are public, everything else needs
authentication, and JwtAuthFilter runs before UsernamePasswordAuthenticationFilter.

The JwtService bean reads the key locations from the Environment with no
default, so startup fails clearly without them. Method security is not enabled
yet, the rbac branch adds it with the permission evaluator.
Payload classes are the HTTP request and response shapes, named differently
from core dto classes on purpose. Requests carry Bean Validation
constraints: an email format, and a password between 8 and 72 characters.
The byte limit of bcrypt is still enforced by the service for multibyte input.

UserConverter maps between the core UserDto and these payloads with static
methods, so the controller never touches a dto directly. Passwords and
tokens are masked in toString.
The controller depends only on AuthService and JwtService and holds no
business rule: register, activate, login (authenticate then issue a JWT),
logout (revoke the token of the authenticated principal), me, forgot and
reset password. Every body is validated and every answer is an ApiResponse.

forgot-password always answers with the same generic message, so the
endpoint cannot be used to discover which emails are registered.
@AuthenticationPrincipal injects the AuthenticatedUser built by the filter.
Exceptions thrown inside a controller reach the advice before the security
filters, so an AccessDeniedException raised by method security would fall
into the catch-all and become a 500. The advice now maps
AuthenticationFailedException and Spring's AuthenticationException to 401 and
AccessDeniedException to 403, all as ApiResponse.

This fills the spot the core-architecture branch left open for it.
WebMvcConfig now imports the auth ServiceConfig, by fully qualified name
since every core module has a ServiceConfig, and SecurityConfig. One
ObjectMapper bean is shared by the MVC converter and the security handlers so
all JSON has the same format.

WebAppInitializer registers a DelegatingFilterProxy that looks up the
springSecurityFilterChain bean in the root context, which is why a single
shared context matters. It is registered after the character encoding filter.
The DAO tests of the auth module run against a real PostgreSQL, so the
module declares the Testcontainers JUnit and PostgreSQL artifacts, and the
common test-jar that holds the shared singleton container, all with test
scope.

They stay in this module rather than the parent pom because only modules
with DAO tests need them.
Each DAO test class loads its own dedicated SQL fixture through @SQL before
every test, which truncates the tables it touches with RESTART IDENTITY and
inserts its own rows, so no dataset is shared between classes. UserDao tests
cover case-insensitive lookups, generated keys, a duplicate email differing
only by case, and the joins returning distinct sorted permission codes.

The token DAO tests cover insert, lookup by digest, marking a token
validated, deletion by user, and the purge counts of the blacklist.
Each RowMapper is fed a Mockito mock of java.sql.ResultSet stubbed column by
column, so no database is involved, including the case of a NULL
validated_at. UserMapper is tested with hand-built objects and no mocks:
toDto never copies the password hash and toEntity uses the hash it is given.

These are the fastest tests of the strategy and pin down the mapping
contracts the DAO and service layers rely on.
… DAOs

The service is tested with Mockito DAOs, a real low-cost bcrypt encoder and a
fixed Clock so expiry rules are exact. The tests cover the 72-byte limit with
multibyte characters, the digest-only storage of tokens, one identical error
for an unknown email and a wrong password, a password over 72 bytes treated as
invalid credentials, the inactive-account error only after a correct password,
an idempotent logout and a forgotPassword that never throws.

ExpiredTokenCleanupJobTest calls run() directly, which is why the scheduled
method is public.
AuthTestSupport registers, activates and logs in a user through the real
endpoints and returns the JWT, reading raw tokens from the INFO log with a
logback ListAppender since no mailer exists. ApiAssertions checks the
ApiResponse envelope of successes and errors, and JwtTestSupport builds the
service on the development keys and forges tokens with other keys.

They are meant to be reused by the controller tests of the later branches.
The tests cover the issue and parse round trip, unique jti values, a tampered
signature or payload, expiry through a fixed Clock including the boundary, a
wrong issuer, a foreign key, and the classic algorithm confusion attacks:
HS256 signed with the public key, alg none and RS384.

JwtKeyLoader is checked for a missing property, a PEM of the wrong type in
both directions, an unreadable file and invalid Base64, each with a clear
failure message.
The real SecurityConfig and a mocked AuthService are driven through
MockMvc with springSecurity() applied. Every route is checked for status and
ApiResponse shape, validation errors are checked per field, and the chain must
answer 401 in the ApiResponse format for a missing, garbage, expired,
wrongly signed, HS256, alg none or blacklisted token.

forgot-password is asserted to return the same status and body for a known
and an unknown email, and logout to build its BlacklistedTokenDto from the
authenticated principal.
The integration test boots the real WebMvcConfig on a Testcontainers database
and walks register, activate, login, /me, logout. The same token then gets
401 while a second token of the same user still gets 200, which proves the
blacklist really rejects a revoked token. It also checks that only SHA-256
digests are stored, the reset flow, single-use tokens, identical answers
for wrong password, unknown email and over-long password, and the cleanup of
expired blacklist rows.

The existing initializer and context tests are adapted to the new security
filter and to the fact that an unknown URL is 401 without a token.
The app service now receives APP_JWT_PRIVATE_KEY_LOCATION and
APP_JWT_PUBLIC_KEY_LOCATION, which Spring maps to app.jwt.* properties, and
mounts ./keys read-only at /keys. There is no default key: without the
mounted pair the application refuses to start.

The README of the compose file now applies to the whole project since
authentication exists. The keys directory is ignored by git.
normalizeEmail throws a BusinessRuleException for a blank value, so a
whitespace-only email produced a 422 while every other input, known or unknown,
gave the generic answer. forgotPassword now handles a null or blank email like
an unknown account and never throws.

The unit test suite gets a non-regression test for it, and the following
test methods are renumbered to keep the sequence.
All nine tasks of the auth branch are checked. They cover the entities and
dto classes, the RowMappers and UserMapper, the DAOs, AuthService with its
constant-outcome forgotPassword, the module wiring, the JWT service and
filter, the controller, the WebMvcConfig imports and the tests at all five
layers plus the full register to login flow.
@EdgarEldy
EdgarEldy merged commit 4cbe777 into develop Sep 19, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant